Conversation
…auto-close
Two hidden power features get a softer learning curve:
1. Long-press Send → Scheduled message / Create task
2. Right-click a chat → Share / Rename / Mark unread / Delete
Three layers of help, all dismissible:
- Discovery pills next to each entry point, themed in `--apple-blue`
like other inline-banner CTAs:
* "Did you know? Hold Send for more" — gated server-side by an
"ever-created" metric (>= 2 tasks AND >= 3 scheduled msgs),
read from sqlite_sequence so it survives row deletion. Browser
throttles to once per day.
* "Tip: right-click a chat for options" — pure client gate;
first contextmenu OR hover-hold on a chat-item flips the
discovered flag forever.
- Hover-hold (1.5 s) opens the same menus as long-press / right-click,
so users who don't think to click-and-hold still find them. Send
button uses mouseenter/mouseleave; chat-items delegate via
mouseover/mouseout with same-target de-dup.
- Hover-intent auto-close (3.5 s) on both menus. Mouse leaves the
menu → 3.5 s countdown; re-enter → cancel. Replaces the schedule
menu's old 10 s blanket timeout.
Plus dropped the native `title="<chat.title>"` tooltip on sidebar chat
items — full title is visible inline, and the browser tooltip got in
the way of the new hover-hold gesture.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The browser tab icon is now canvas-rendered:
- Apple-style rounded corners (28% radius), always — even when idle.
- Up to two status dots, aggregated across all chats/tasks and
de-duped by color: orange (needs-human) > blue (chat ready /
task-review) > green (working). Each dot gets a light ring so it
reads on any base color.
Implemented as a derived view of the sidebar status dots already in the
DOM — no parallel state. Repaints only when the computed verdict
actually changes (200ms debounce), and the dots are static (never
animated), so steady-state cost is zero. Colors are read from live CSS
tokens, so dark theme is respected. Hooks into the existing
setWorkingDot / setUnreadDot / applyTasksNavSignals paths; no new
server events or routes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…fail The home screen rendered a dead-end "Start OpenClaw" message with no input when agents.list failed, while the badge still claimed "connected" — because the badge used the unauthenticated /health probe while the real RPC went over the WS. A gateway reachable on /health but unreachable over WS (wrong port, token mismatch) looked connected but left the user stuck with no clue why. - Derive an honest gatewayStatus (ok/degraded/down): /health alone no longer claims "connected" when the authenticated agents.list RPC can't get through. - Surface the failure: log the baseUrl we tried (a wrong port is the usual culprit) and show a plain-language banner naming the address, with raw error kept under a "Details" line for support. - When the gateway is unreachable, show only the error banner + Try again instead of inviting "Choose a project" for a chat that would fail to send. - Show the same banner on the Projects page; share the logic via a new probeGateway() helper and a gatewayError partial. - Reserve the sidebar "Start OpenClaw" banner for a genuinely-offline gateway (degraded gets the in-page banner — there's nothing to start when it's up). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Opt-in via env: set ICLAW_REMOTE_ACCESS=1 and ICLAW_RELAY_URL=ws(s)://…/tunnel and iClaw will open an outbound WebSocket to the relay on startup. The relay allocates a temporary subdomain, forwards public HTTP requests as `req` frames, and we replay them against the local Express server over loopback and stream the response back as `res` frames. - src/services/remoteAccess.ts — outbound WS client, frame protocol, loopback forwarder, exponential-backoff reconnect, idempotent start/stop. - src/index.ts — wire start() after listen, stop() into graceful shutdown. Raw byte-forwarding only; no auth/encryption layer yet. Invite token, SPAKE2 password handshake and per-device keypair sessions land in follow-up commits on this branch.
Block anyone who reaches iClaw through the relay until they prove they
know the host-side passphrase. Direct localhost users are never
challenged — only requests carrying `x-iclaw-tunneled: 1` (set by our
own loopback forwarder, never trusted from the public side) hit the
gate.
- src/services/remoteAccessAuth.ts (new)
* 4-word + 3-digit diceware passphrase (~35 bits, online-only attack)
* in-memory session store, 24h TTL, swept every 5 min
* 10-attempts-per-5-min per-IP login rate limit
* constant-time passphrase compare
* self-contained inline login HTML (no external assets, so 401 doesn't loop)
* HttpOnly + SameSite=Lax cookie; Secure flag added when behind https
- src/app.ts
* mount auth middleware after body parsers, BEFORE static so assets
are gated too
* mount POST /__ra/login handler
- src/services/remoteAccess.ts
* generate passphrase on start() and print it prominently in the log
* strip incoming x-iclaw-* from frame headers (defence in depth with
the relay-side strip that lands in iclaw-relay)
* inject x-iclaw-tunneled: 1 on the loopback request
* disableGate() on stop()
Threat model (v0.1): relay is honest-forwarder; it can technically read
the passphrase as it streams through during login because we haven't
moved to SPAKE2 yet. Full E2E "relay never sees the password" lands as
a follow-up. Today's win is: anyone who finds the subdomain URL without
the passphrase still can't see anything.
Pairs with the relay-side WS bridge. iClaw's UI uses /ws for live chat streaming and gateway events; without this, remote-access only delivered the initial HTML and the page sat there static. What lands: - Three new tunnel frames recognised in the message handler: ws-open, ws-data, ws-close (s-XXXX stream-id namespace, distinct from request ids). - On ws-open: defence-in-depth strip of any incoming x-iclaw-*; run the same password-gate session check used by HTTP middleware (isValidTunnelSession in remoteAccessAuth, newly exported); if invalid, send ws-close 4401 and bail. Otherwise open a loopback WebSocket to ws://<localHost>:<localPort><frame.path> with `x-iclaw-tunneled: 1` injected. - Per-stream pending queue: ws-data frames that arrive while the loopback handshake is still in CONNECTING state get buffered and flushed on 'open' instead of being silently dropped. Without this, a fast public client racing the loopback handshake would lose its first message. - ws-data / ws-close routing in both directions; cleanup on either side notifies the other. Threat model unchanged: relay sees ciphertext-of-TLS (operator-trust), the password gate is enforced end-to-end of the local iClaw. E2E smoke: WS via tunnel with cookie → ping/pong round-trip; without cookie → close 4401; direct localhost WS unaffected.
End-users now enable / disable Remote Access from inside iClaw — no env
vars, no restart. Pick a duration (30 min / 12 h / 7 d / 30 d) and the
tunnel auto-stops when it expires; iClaw remembers the state across
restarts so a 30-day tunnel survives quitting the app.
Surface:
- GET /remote-access — Settings page (in sidebar nav)
- GET /api/remote-access/status — current state + allowed durations
- POST /api/remote-access/start — { durationMs } from the fixed list
- POST /api/remote-access/stop — fully tear down + clear persisted state
Pieces:
- src/db/database.ts — new singleton `remote_access_state`
table (enabled / passphrase / duration /
started_at / expires_at)
- src/services/remoteAccessState.ts — typed get/save/clear over the row
- src/services/localAddress.ts — stash bound host:port so the API
doesn't need to know about server
internals
- src/services/remoteAccess.ts — refactored:
* startWithDuration() — UI path,
persists + schedules auto-stop
* resume() — startup re-attach
when expires_at is still in the
future (clears the row otherwise)
* stopNow() — UI "Disable" — closes
socket + drops the gate + clears
persisted state
* stop() — graceful shutdown only;
keeps the persisted row so we
auto-resume on next start
* getStatus() — surface for the API
* ALLOWED_DURATIONS_MS — the four
fixed values; API rejects anything
else
* getRelayUrl() — env override or
ws://127.0.0.1:4100/tunnel default
- src/routes/remoteAccessApi.ts — JSON API (zod-free, simple
number-in-allow-list validation)
- src/routes/remoteAccessPage.ts — GET /remote-access page, reuses the
sidebar/head/foot partials so it
looks like the rest of the app
- src/index.ts — on startup: register bound address,
then either resume() from DB or
fall back to the legacy env path
- views/remote-access.ejs — settings UI: duration picker when
disabled; URL + passphrase + expiry
countdown + Disable button when on.
Inline JS polls /status every 1.5s
so the URL appears live once the
relay handshake completes, and the
remaining-time counter ticks down.
- views/partials/remoteAccess{Disabled,Enabled}.ejs — first-paint
contents of the card (script
re-renders on state changes).
- views/partials/sidebar.ejs — "Remote access" link in the main nav.
E2E smoke (17 sub-checks): page renders, status disabled by default,
POST start gets URL + passphrase, browser-friendly URL via lvh.me hits
the login gate, login + 200 iClaw HTML, settings page shows enabled
state with the URL, sidebar link present, POST stop clears state, URL
404s after stop, invalid duration → 400.
Persistence (4 sub-checks): kill iClaw with an active tunnel; restart;
state comes back enabled with the original passphrase; iClaw reconnects
to the relay (which assigns a fresh subdomain since the previous WS is
gone); "resuming (UI)" log line confirms the resume path.
Threat model unchanged from the password-gate slice. Passphrase is
stored plaintext in the local SQLite — same trust assumption as every
other iClaw secret.
…-affect
Singleton tunnel → collection. A user can now create as many tunnels as
they want, each with its own duration, passphrase, public URL, and
connected sessions. Disabling one never affects another.
Schema:
- Drop singleton remote_access_state (kept as a dead table for in-place
upgrades; can be removed in a follow-up).
- New remote_access_tunnels(id PK, label, passphrase, duration_ms,
started_at, expires_at, created_at) with idx on expires_at.
State:
- remoteAccessState rewritten: list / get / save / delete / clearAll
over the new collection. Tunnel ids are random `t-xxxxxxxxxx`.
Auth (per-tunnel):
- passphrases: Map<tunnelId, passphrase>; enableGate/disableGate per id.
- sessions: Map<sessionId, {tunnelId, expiresAt}>; the middleware
verifies that a presented session id belongs to the exact tunnel id
the request is for, so a hand-crafted cross-tunnel cookie replay
is refused even though browser cookie scoping already prevents the
common case.
- New `x-iclaw-tunnel-id` header (injected by the loopback forwarder,
stripped from any incoming public request) tells the middleware
which tunnel's gate to consult.
- Login rate-limit keyed on (ip, tunnelId) so flooding one tunnel
doesn't burn another's budget.
Runtime (multi-tunnel):
- remoteAccess.ts rewritten around `Map<tunnelId, RuntimeTunnel>`. Each
RuntimeTunnel owns its own outbound WS, expiry timer, currentUrl, and
per-stream loopback connections.
- createTunnel(durationMs, label?) — fresh id+passphrase, save, connect,
schedule expiry.
- deleteTunnel(id) — closes WS, clears its sessions, removes from DB.
- resumeAll() — at startup, re-attach every persisted tunnel whose
expires_at is still in the future. Expired rows are deleted.
- shutdown() — graceful: close everything in memory but DON'T touch
persisted rows (so the next startup auto-resumes).
- Legacy env-driven activation (ICLAW_REMOTE_ACCESS=1) is removed; UI is
the only way to enable now.
API:
- GET /api/remote-access/tunnels → { tunnels, allowedDurationsMs }
- POST /api/remote-access/tunnels → { durationMs, label? } → 201 status
- DELETE /api/remote-access/tunnels/:id → { ok: true } | 404
- Old /start /stop /status endpoints removed; UI fully on the new ones.
UI:
- views/remote-access.ejs rewritten as a list: top "create new" card
(duration radios + button) followed by a card per active tunnel
with URL, passphrase (copy buttons), live expiry countdown, and a
per-tunnel "Disable this tunnel" button.
- Polling continues every 1.5s while the page is visible; remaining
time ticks every second without re-rendering the whole list.
- Legacy partials remoteAccessDisabled/Enabled removed.
Relay: no changes — multi-tunnel was already supported on its side (each
WS connection from iClaw gets its own subdomain in the relay's hub).
E2E smoke (20 sub-checks):
- create A (30 min) + B (12h) → distinct ids, distinct passphrases,
distinct URLs;
- log in on A → 200 on A;
- A's passphrase rejected on B (per-tunnel passphrase isolation);
- A's cookie rejected on B (per-tunnel session isolation);
- log in on B → both work independently;
- DELETE A → A becomes 404, B keeps serving 200;
- DELETE B → list empty;
- DELETE nonexistent → 404; bad duration → 400.
Remove the standalone "Remote access" sidebar link and route. The feature moves into a Settings page reached via a small gear button positioned to the left of "New chat" — the entry point Apple-style apps train users to look for. Sidebar: - New `.sidebar-settings-btn` (icon-only, 38×38, mirrors the existing search-toggle's visual language so the toolbar reads as one row). Has an `.active` state when on the Settings page. - Removed the explicit "Remote access" item from `.sidebar-projects-nav`. Routing: - `routes/settings.ts` mounts `GET /settings` (and is wired into app.ts in place of the old remoteAccessPageRouter). - `views/settings.ejs` replaces the standalone Remote Access page. - Legacy `routes/remoteAccessPage.ts` + `views/remote-access.ejs` removed entirely. `/remote-access` now 404s. - The JSON API (`/api/remote-access/tunnels` etc.) is untouched — the page move is purely a URL / UI change, not a feature change. Design (Settings page): - iOS-y segmented-control style duration picker (pill rail with a selected segment, no radios visible) instead of separate radio cards. - iOS blue (#0a7aff) primary pill for "Create tunnel"; destructive red (#ff453a) for "Disable". - Tunnel cards are softly tinted surfaces with hairline dividers between rows (no heavy borders), a tiny green dot + monospaced short id in the header, and a quiet right-aligned remaining-time counter. - 34px title, generous 56px gap below it, 540px content column — whitespace doing the structural work instead of boxes. - Mobile breakpoint: stacked picker + full-width primary button. E2E smoke (14 sub-checks): /settings renders with title + RA section + create form; /remote-access 404s; sidebar has gear linking to /settings, no legacy RA item; active class on /settings; API endpoints unchanged; created tunnel shows up in the server-rendered card on next page load.
The previous pass leaned too hard on an iOS aesthetic that didn't match the rest of the app. Rewritten to use iClaw's own design tokens and component classes. Sidebar gear: - Shrink from 38×38 with 20×20 icon → 28×28 with 16×16 icon. Borderless ghost-style with subtle hover background, matching how iClaw's other inline icons behave. Settings page: - Layout container mirrors `.projects-hub-page` (max-width 32rem, centered, padding via --space tokens). - Title: 1.5rem / 600 / letter-spacing -0.03em — same as projects-hub-title. - Section heads use --text-sm description below a 1.05rem title; no more oversized iOS-style 34px hero. - Buttons switched to native `.btn` `.btn--primary` `.btn--danger` `.btn--ghost` `.btn--sm` instead of custom ra-*-btn classes. Disable is now the iClaw danger pattern (subtle red background tint, not destructive red text). - Status indicator uses the existing `.chip` `.chip--ok` chip. - Tunnel cards use `.card` with hairline row dividers via `--border` color-mix. - Duration segmented control replaced with simple bordered options that pick up `--accent` when selected — fits with how iClaw already highlights selected items elsewhere. - All colors via CSS vars (--text, --muted, --border, --surface, --accent, --md-link, --danger); the old #0a7aff / #ff453a / #30d158 hex literals are gone. E2E sanity (7 checks served HTML): ✓ uses btn / btn--primary / btn--ghost / btn--sm ✓ uses chip--ok ✓ uses .card ✓ no legacy ra-*-btn classes ✓ no iOS hex literals ✓ gear button present ✓ gear is 28×28 in CSS
The previous pass shipped a functional but bland page — internal #id
treated as data, all rows visually identical, URL hidden among labels,
empty state was a plain "No active tunnels" line.
Changes:
Tunnel card
- Drop the internal `#aabbcc` ID from the header — it's not useful to
the user, just noise. Header is now just the Live chip + remaining
time on the right.
- "Enabled" chip → "Live" with a pulsing dot. Conveys "this is actually
serving right now" rather than just a setting state.
- Card promoted to `card--accent` so live tunnels read as the live thing
on the page (subtle left-border accent + tinted bg).
- URL row redesigned as a "hero" block: full-width card-in-card with an
accent-tinted background, monospaced URL, and an external-link icon
on the right of the link itself (so you can tell it's clickable).
Hover lifts the bg + icon color.
- Copy buttons are now icon-only 32×32 squares with subtle hover state;
flip to a green checkmark with success bg for 1.2s after copy. Less
visual weight than text+icon, more findable than plain text.
- Passphrase row promoted to a 3-column grid so the label, value and
copy button line up regardless of passphrase length.
- "Disable tunnel" is a ghost button by default and only turns red on
hover, so a destructive action doesn't dominate the card visually.
- Remaining-time text reads "29s left" / "11h 56m left" / "6d 23h left"
instead of bare "29s" — clearer without an explicit "Expires in".
Create-tunnel form
- "+" icon on the primary button so the action reads as "add", not just
generic "Create".
- Slightly tighter padding on the wrap.
Empty state
- Proper empty state with a small unlink icon + headline + hint
("Create one above to start sharing access."), dashed border around
it. The earlier <p>No active tunnels.</p> looked broken next to the
filled cards.
Misc
- Page max-width 32rem → 36rem so the URL doesn't ellipsis-truncate on
desktop.
- Pulse animation on the Live dot (gentle 2s breathe).
- Spinner on "Connecting to relay…" instead of just a ⟳ glyph.
- Mobile breakpoint: passphrase row stacks; form goes vertical.
Same iClaw native tokens (`.btn`, `.card`, `.chip`, `--text`, `--muted`,
`--accent`, `--border`, `--ok`, `--danger`, `--md-link`, `--surface`,
`--space-N`, `--radius-md`). No iOS hex literals, no own design system.
…styling
Three things from the latest pass of feedback:
1) "Прибиту картку" — softer card styling
- Drop the `card--accent` left-border. Cards are now plain surface with
a hairline border that slightly darkens on hover.
- URL block keeps a prominent inset look but uses a neutral text-tinted
background instead of the accent-tinted one (less "alarm" feel).
- Passphrase row now also gets a matching inset block + copy button,
giving the two main rows the same visual rhythm.
- "Live" status is now a small green dot + word (pulse animation kept)
instead of a chip — softer, less status-bar-y.
- "Disable" is a tiny ghost link in the footer rather than a button.
- Tunnel card padding bumped from 4 to 5 + 4 for breathing room.
- Hover gives a 1px shadow nudge, not a heavy lift.
2) "Менше часу — вище" — sort by expiresAt
- `remoteAccess.list()` now sorts by expiresAt ascending (createdAt as
tiebreaker). A 30-min tunnel made now will sit above a 7-day tunnel
made yesterday, and tunnels close to expiring rise as time passes.
3) "Створити тунель це спливаюче меню" — native modal
- "New tunnel" is now a primary button next to the section title
instead of an inline form. Clicking it opens a `<dialog>`-based modal.
- Modal fields:
- Name (optional, max 64 chars, free text). Empty stays NULL in DB.
- Duration (4 radio chips, 12 hours preselected). 2×2 grid.
- Submit posts {durationMs, label} to the API (which already accepted
label since the multi-tunnel slice). Cancel / ✕ / backdrop click /
Escape all close the modal cleanly.
- Tunnel card now shows the label inline in the head row between the
Live indicator and the remaining-time counter (ellipsis-truncates if
too long).
- Backdrop uses a subtle dim + 4px backdrop-filter blur; modal itself
has a 0.18s scale-up open animation.
Empty state stays its own dashed-border block; "+ inline form" was
removed entirely, so a fresh iClaw with no tunnels looks intentional
rather than half-filled.
Same iClaw native tokens throughout (`.btn`, `.btn--primary`,
`.btn--ghost`, `--surface`, `--border`, `--text`, `--muted`, `--accent`,
`--ok`, `--danger`, `--md-link`, `--space-N`, `--radius-md/lg`,
`--text-xs/sm/md`). No iOS hex literals.
E2E smoke: labels survive the round-trip ({"label":"Anna Mobile"} in
response), sort order places a 30-min tunnel above a 7-day tunnel,
settings page markup contains the modal + open button + label class.
Pairs with the relay-side multiplexing refactor. Previously each RuntimeTunnel held its own outbound WS, leading to per-tunnel reconnect storms and rate-limit starvation when more than a couple tunnels were active. Now one module-level WS for the whole iClaw process; every RuntimeTunnel is just an entry in tunnels:Map<tunnelId,RuntimeTunnel> and frames flowing in/out carry the tunnelId so we can demultiplex. Lifecycle: - configure(opts) — set bound relay URL + local host/port. - resumeAll() on startup — load persisted tunnels whose expiresAt is in the future, register them once the shared WS opens. - createTunnel(durationMs, label?) — mint id+passphrase, persist, send register-tunnel (or queue until WS opens via ensureConnection). - deleteTunnel(id) — send unregister-tunnel, clear local state, drop persisted row. - shutdown() — graceful process exit; closes WS without touching persisted rows. Reconnect: - On socket 'close' we mark every tunnel's currentUrl = null so the UI shows "Connecting…" again, then scheduleReconnect() with exponential backoff. - On the next 'open' we re-register every tunnel in tunnels:Map and set currentUrl back when tunnel-registered comes in. Subdomains will be fresh — the passphrase/tunnelId stay the same. Header injection on loopback unchanged: x-iclaw-tunneled: 1 + x-iclaw-tunnel-id: <id> so the password gate knows the request came through the tunnel and which tunnel it belongs to. Per-stream WS bridging unchanged shape, just keyed by tunnelId instead of being implicit in the per-tunnel WS. E2E (smoke): 4 fresh tunnels each get URLs over a single relay connection; killing the relay forces an iClaw reconnect that re-registers all 4 with new subdomains (passphrase preserved).
The relay is now publicly deployed on viralo behind the existing Cloudflare Tunnel: - relay.iclaw.digital → http://localhost:4100 (the relay's apex) - *.iclaw.digital wildcard → http://localhost:4100 (per-tunnel subdomains) Default the iClaw client to it. Env override (ICLAW_RELAY_URL) still wins so dev against a local iclaw-relay keeps working. Net UX win: fresh iClaw install can enable Remote Access from the Settings page with zero additional setup — no second terminal, no running iclaw-relay locally.
Two polish passes that the rendered UI was visibly missing.
New-tunnel modal
- Bigger title (1.05rem semibold) + one-line subtitle explaining what
the action does ("Generates a temporary URL and passphrase…").
- Duration picker switched from minimal pill row → 2×2 cards. Each
card has a main label ("12 hours") + sub-hint ("today"), and the
selected card gets a soft accent tint, an accent border AND a check
icon in the top-right (so the choice is unambiguous at a glance).
- Field labels promoted to text-strong with a smaller, hint-style
second line, so the meaning of "Name (optional)" / "Expires after"
reads at a glance.
- Modal max-width 26rem → 28rem so the duration grid breathes.
- Heavier shadow + better backdrop (55% black + 6px blur) so the modal
feels properly on top of the page.
Login page (served by iClaw, reached via tunnel)
- Added a header bar with the iClaw favicon + wordmark linking to
https://iclaw.digital — matches how the other iClaw properties
(landing, cloud) brand themselves at the top.
- Title is now "Enter the passphrase" with a small "Remote Access"
eyebrow pill above it (with a pulsing accent dot), so the page
identifies what feature it is without the prior generic wording.
- Subtle radial accent glow at the top of the page; card has a
proper inset highlight + 24px shadow stack.
- Focused input gets a 3-px accent ring (instead of a 2-px solid
outline that clashes with the border).
- Footer with "Powered by iclaw.digital" hyperlink.
- Local-served-not-relay reassurance is now in the card foot under a
hairline, not floating below the form.
- Mobile breakpoint (<480px): tighter card padding, smaller header.
All native-token styling on the modal (`.btn`, `.card`, `--space-*`,
`--text`, `--muted`, `--accent`); the login page is intentionally
self-contained inline CSS (it has to render even if iClaw's main
stylesheet is unreachable through the tunnel).
Plain link/password rows instead of input-style fields, click-to-reveal passphrase, smarter expiry labels, Share CTA, and clearer section text without duplicate security footnote or Live badge. Co-authored-by: Cursor <cursoragent@cursor.com>
Polish the share sheet (grouped name, segmented duration, required label), simplify tunnel cards and empty state, add adaptive polling, and require label on tunnel creation in the API. Co-authored-by: Cursor <cursoragent@cursor.com>
Use the shared design system (style.css) with grouped-field layout, allow gate assets before auth, and drop the standalone blue theme. Co-authored-by: Cursor <cursoragent@cursor.com>
Replace fixed-interval polling with nextPollIn scheduling, setTimeout chaining, expiry-aware delays, error backoff, and a 5-minute sanity cap. Co-authored-by: Cursor <cursoragent@cursor.com>
Feat/remote access
routes.projects.test.ts mocked `services/openclaw` without `health()`,
but src/routes/projects.ts → probeGateway → openclaw.health() now needs
it (probeGateway was widened a while back). Two tests have been failing
on dev as a result:
TypeError: openclaw.health is not a function
at probeGateway (src/services/gatewayProbe.ts:30)
at GET /projects handler
- "renders the hub even when no projects exist"
- "lists projects, sorted by activity"
Mirror the shape used by the other gateway-exercising tests
(routes.gateway.test.ts, routes.taskAsk.test.ts): add
`health: vi.fn(async () => true)` to the mocked openclaw.
After: 303/303 pass.
Ping the relay every 30s so Cloudflare does not drop idle tunnel connections. Keep the last public URL in the UI during brief outages; relay sticky restore returns the same subdomain on re-register. Co-authored-by: Cursor <cursoragent@cursor.com>
Slide-out sidebar with backdrop, compact chat header (icons on phone, text on desktop, chat title hidden on narrow screens), and safe-area padding across settings, projects, tasks, and composer. Co-authored-by: Cursor <cursoragent@cursor.com>
…essions Remove legacy trusted-relay mode and REMOTE_ACCESS_E2E flag. Tunneled login uses OPAQUE; app traffic uses encrypted HTTP/WS. Add vendor copy/verify, device trust/revoke, access token gate, and unit/smoke tests. Co-authored-by: Cursor <cursoragent@cursor.com>
…fixes Lands the E2E-only Remote Access alpha and the fixes from the security audit. Tunneled HTTP/WS payloads are encrypted browser↔local-iClaw; the relay forwards ciphertext only and never sees the passphrase. Feature: - OPAQUE (RFC 9807, @serenity-kit/opaque) passphrase login — passphrase never crosses the wire; tunneled POST /__ra/login is rejected (426). - Encrypted HTTP (/__ra/e2e/http) + WS (/__ra/e2e/ws) transport with a browser fetch/WebSocket shim; plaintext over the tunnel limited to a small exempt set (gate assets, OPAQUE/device/e2e bootstrap, unauthenticated GET /). - Device sessions (browser keypair; private key never leaves the browser) with per-device revoke. - Vendored crypto assets (@noble/hashes, @serenity-kit/opaque) with copy + verify scripts in pre/postinstall. Post-audit fixes: - C1 (critical) AES-GCM nonce reuse: the 12-byte nonce is derived from (tunnelId, counter) only, and counters restart per stream, so two streams reused (key, nonce) under one direction key — catastrophic for GCM and trivially decryptable by the relay. Fixed by deriving a per-(direction, streamId) AEAD subkey via HKDF from the direction key, in both src/services/remoteAccessE2eCrypto.ts and the browser mirror public/js/ra-e2e-crypto.mjs (byte-identical; verified vendored noble HKDF == npm noble HKDF). Corrected the false "XChaCha20" header comment. Regression test: two streams, same ctr → distinct ciphertext + cross-stream decrypt fails. - H4 (high) fail-fast: POST /api/remote-access/tunnels returns 503 opaque_setup_missing when OPAQUE_SERVER_SETUP is absent, instead of minting a tunnel nobody can log into. - H2 (high) honest UI copy: the Settings security panel no longer claims cookie privacy; it now states the relay sees metadata + access/session cookies, while passphrase and payloads stay private. - H3 (high) docs/REMOTE_ACCESS.md written (setup, OPAQUE_SERVER_SETUP, local vs host, security model, troubleshooting); UI no longer points at a missing file. Known open (tracked, NOT fixed here): - H1: the relay still sees the iclaw_ra cookie (Set-Cookie on OPAQUE finish + outer E2E requests). It is HttpOnly and not load-bearing for the E2E data path (inner requests authenticate via the OPAQUE transport session), so content stays private after C1 — but the cookie exposure remains. Safe removal needs a relay→browser→iClaw integration test first; changing the auth path blind risks breaking login. Tests: 353 pass (incl. C1 regression). typecheck clean.
In the native (non-codex) agent loop the gateway commits each assistant segment as its own transcript row, so the preamble that precedes a tool_use is emitted as an intermediate session.message / chat:final BEFORE the tools run. runTurn settled on those (1s grace), truncating the turn to just the preamble and dropping the real answer that follows several tool round-trips. Codex hid the loop and emitted one final at the end, so it was unaffected. Gate turn settlement on lifecycle:end — the one canonical run terminator that fires once after every tool round-trip. chat:final / session.message now only capture text; they re-arm the short settle grace only after lifecycle:end. Drop the now-redundant 15s end watchdog. Add native-loop regression tests and update existing completion tests to emit lifecycle:end as the terminator. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Keep the live scheduled list in chronological order after add, edit, and page load. Co-authored-by: Cursor <cursoragent@cursor.com>
…aintext path Plaintext tunnel requests are never authenticated now: strip iclaw_ra on the loopback so a stale session cookie can't cause an 'E2E transport required' dead-end or serve the workspace in the clear. Any top-level HTML navigation (any path, incl. /chats/:id) is answered with the gate bootstrap, which resumes the E2E session from sessionStorage (no passphrase on same-tab nav/reload/back) or prompts for the passphrase. Data XHRs stay forced to E2E. - Device verify no longer Set-Cookies iclaw_ra (leaked to the relay and can't establish E2E keys); returns needsPassphrase instead. - Passphrase login reuses the registered device instead of minting a duplicate on every login; revoked devices are dropped from local IndexedDB. - installRaE2eTransport is idempotent (was double-wrapping fetch -> recursion after document.write); unified transport import URL + cache-buster ra-gate-6. - Browser<->server crypto.subtle interop test (loads real ra-e2e-crypto.mjs). - copy-opaque-vendor strips dangling sourcemap comments. - Remove the Settings security panel (model lives in docs/REMOTE_ACCESS.md). - Integration test asserts nav=200, no iclaw_ra leak, concurrent sessions, returning-visitor + deep-link bootstrap, and C1/C2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface pending send-later state in the chat list with live WS updates, working > unread > scheduled priority, and favicon sync. Co-authored-by: Cursor <cursoragent@cursor.com>
Replaces fuchsia so the indicator stays visible but does not compete with unread blue. Co-authored-by: Cursor <cursoragent@cursor.com>
The settings page omitted the shared client bundle that wires sidebar search and live chat list updates. Co-authored-by: Cursor <cursoragent@cursor.com>
The access URL remains a plain link that opens in a new tab. Co-authored-by: Cursor <cursoragent@cursor.com>
… config)
A fresh install no longer needs a manual `npx create-server-setup` + env step.
The OPAQUE server setup is now resolved as: OPAQUE_SERVER_SETUP env override ->
value persisted in the local SQLite -> lazily generated and stored on first
tunnel creation. The env var still wins (advanced / shared-host: set the same
value on several machines to share a tunnel/passphrase).
- ensureOpaqueServerSetup(): lazy mint+persist, single-flight; loadServerSetup()
prefers env then DB.
- POST /api/remote-access/tunnels auto-provisions instead of returning the H4
503 'opaque_setup_missing'; the 503 ('opaque_setup_unavailable') now only
signals a genuine OPAQUE-runtime failure.
- Startup bootstrap also ensures a setup when tunnels exist (e.g. a previously
set env was removed) so resumed tunnels re-register and stay loggable.
- Works on any OS iClaw runs on (OPAQUE is WASM; stored in the existing SQLite).
- Tests: auto-gen + env-precedence; reframed the service-layer guard test.
- docs/REMOTE_ACCESS.md: setup is automatic, env documented as optional.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…workspace)
After an E2E-delivered page is written to the DOM, the browser loads its
stylesheets/scripts/icons via <link>/<script> tags, which bypass the fetch
wrapper and so can't be E2E-wrapped. They were hitting the plaintext path and
getting 426 'E2E transport required' -> the workspace rendered with no CSS/JS
('can't see messages').
Serve public app-shell assets (/css/*, /js/*, favicons) in the clear from BOTH
gates (the tunnel plaintext-exempt check AND the gate middleware). They carry
no user data (identical for every visitor). User data stays E2E: HTML pages,
/api, /uploads, /media, WebSocket.
- isPublicStaticAsset(): single source of truth in remoteAccessAuth, used by
the gate middleware and isE2ePlaintextTunnelExempt.
- Tests: unit (static vs user-data boundary) + integration (/js/iclaw.js served,
/uploads stays E2E-gated).
- docs: security model updated (app-shell plaintext, user data E2E).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The E2E WebSocket wrapper compared u.origin !== location.origin, but iclaw.js
opens the socket with an absolute wss:// URL whose origin ('wss://host') never
equals the page origin ('https://host'). So every chat WebSocket fell through
to a direct /ws connection, which iClaw rejects (4406 'encrypted WebSocket
required') -> endless reconnect loop -> messages never sent/received. (fetch was
fine: iclaw.js uses relative URLs, whose origin matches.)
Compare host instead of origin so same-host wss:// is routed through the
encrypted /__ra/e2e/ws bridge. Bump gate cache-buster to ra-gate-7 so browsers
pull the fixed transport (kept all transport import URLs identical for the
single-module-instance idempotency guard).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add [ra-dbg] logging (gated by localStorage.ra_debug='1' or ?radebug=1) across ra-device-auth init + ra-e2e-transport navigateViaE2eDocument/installRaE2eTransport to trace where the browser-side E2E resume stalls (server + protocol already verified working end-to-end). Logs the previously-swallowed resume error. Cache-buster -> ra-gate-8. No behaviour change when the flag is off. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ns/manifest) The static allowlist missed root files like /icon-192.png (dynamic favicon), which 426'd. Broaden isPublicStaticAsset to root single-segment files with a static extension (png/ico/svg/webmanifest/json/woff…) — covers favicons, PWA icons and the manifest while never matching user data (/uploads,/media,/api or page routes are multi-segment / extension-less). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nd') Add [ra-dbg:iclaw] logging (gated by localStorage.ra_debug='1' or ?radebug=1) around connectWs (open/close/error + readyState), wsSend (type + readyState + open?), and the E2E boot→connectWs trigger. Pinpoints whether the chat WebSocket actually reaches OPEN in the browser (it does in a Node client), which is the gate for sends to flush. No behaviour change when the flag is off. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
iclaw.js was loaded without a cache-buster, so browsers (and Cloudflare) served a stale copy — which is why the [ra-dbg:iclaw] logs never appeared. Add ?v=dbg1 to the iclaw.js script tag in all views, and log every WS frame iclaw.js receives ([ra-dbg:iclaw] WS recv) plus every frame the E2E transport wrapper decrypts/dispatches ([ra-dbg:transport] WS recv … decrypt OK/FAIL, listeners N). Gated by localStorage.ra_debug. Lets a clean single-message test show iclaw.js's real WS lifecycle (open/recv/wsSend). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…p-alive; drop debug Three browser-transport bugs blocked sending over the encrypted tunnel: - WebSocket.OPEN was undefined after the shim replaced window.WebSocket (static readyState constants weren't copied), so `ws.readyState === WebSocket.OPEN` was always false and wsSend() never transmitted. Copy CONNECTING/OPEN/CLOSING/CLOSED onto the replacement constructor. - socket.send() base64-encoded string frames with btoa(), which throws on non-Latin1 input; a .catch() swallowed it, so any message with non-ASCII content (e.g. Cyrillic) was silently dropped. UTF-8 encode before base64 (mirrors the inbound TextDecoder path), chunked for large payloads. - Add a 25s app-level WS ping so idle sockets survive Cloudflare's ~100s WebSocket idle timeout, including gaps between agent stream frames. Also remove the temporary diagnostics used to find these (server-side file logging, /__rtrace client beacons, gated [ra-dbg] console logging) and tidy the asset cache-busters (iclaw.js ?v=1; gate/transport assets ?v=ra-gate-9 so clients pick up the transport fix). Verified end-to-end over a live tunnel (Ukrainian message -> full agent turn) plus 365 unit tests and the RA E2E integration test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
remoteAccessToken.test.ts imported the relay's hashAccessToken from the sibling iclaw-relay repo via a relative path. That resolves in local dev (both repos checked out side by side) but not in CI, which builds iClaw alone — the missing module failed the whole suite (and had been failing dev CI on the last few pushes). Load the relay module via a guarded dynamic import and gate the parity assertion with it.skipIf: it still runs locally (verifying iClaw and the relay hash access tokens identically) and skips cleanly in CI instead of erroring. iClaw's own hash stays pinned to a spec-derived fixture in a separate always-on assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When iClaw starts on a machine with no local browser (a headless server) it now offers, in the startup-banner style, to bring up a private remote-access link so the operator can reach it from a phone or laptop without ever opening a browser on the box. Detection is automatic — no DISPLAY/WAYLAND on Linux means headless; macOS/Windows always have a browser. Startup UI is driven by environment, not by how it was launched: - interactive terminal + local browser (npx OR npm run dev) -> the polished banner. Raw-mode key controls (g to open the browser) attach only on a real CLI launch (npx/iclaw); under `npm run dev` tsx-watch owns stdin, so the banner shows without them and Ctrl+C falls back to the normal signal. - headless server with a terminal -> the remote-access onboarding: a [Y/n] splash (Yes creates a 30-day link, No falls back). The terminal stays interactive — S shows the link(s), Ctrl+C stops. Link + passphrase auto-hide after a minute; resting screens show the local address and active-link count. Already-configured restart: a quiet confirmation, no re-printed secret. - piped / service start (systemd, docker) -> a functional one-line log + a hint. Also: - Fix scheduleExpiry overflowing setTimeout's 2^31-1 ms (~24.8-day) ceiling — a 30-day link was firing its expiry immediately; clamp and re-arm the remainder. - Quiet routine remote-access info logs while onboarding owns the terminal. - Always restore the terminal's raw mode on exit (survives tsx-watch reloads). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(remote-access): headless onboarding + consistent startup UI
Client side of the relay subdomain-hijack fix. The relay previously restored a reconnecting tunnel on tunnelId alone, letting a stranger who learned the (short, relay-logged) tunnelId claim the subdomain. Now each tunnel carries a 256-bit ownership secret, sent to the relay on register-tunnel as `ownerProof` (relay stores SHA-256 only) and required for any cross-connection restore/rotate. - DB: new owner_secret column (idempotent ensureColumn migration). - remoteAccessState: persist/backfill ownerSecret (ensureOwnerSecret); generateTunnelId widened from 5 bytes (40-bit) to 16 bytes (128-bit) so the id is no longer a practical targeting primitive. - remoteAccess: thread ownerSecret through RuntimeTunnel and emit it as ownerProof on every register (create / resume / reconnect). Local-only secret; never leaves the machine except as a hash. Backward compatible with relays that ignore the field. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The trusted-device Ed25519 key was generated extractable and persisted in IndexedDB as an exportable pkcs8 blob, so an XSS on the tunnel could read the device identity straight out of storage and impersonate the browser. Generate the keypair non-extractable and persist the live CryptoKey (structured-clone keeps it opaque in IndexedDB); the private key never leaves WebCrypto. Public key stays exportable for spki registration. Backward compatible: resolveSigningKey() and all device-presence checks accept either the new CryptoKey (privateKey) or a legacy pkcs8 blob (privateKeyJwk), so already-trusted browsers keep working until their next passphrase login mints a non-extractable key. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task plan/execution/ask prompts didn't pin a response language, so OpenClaw
often defaulted to English even when the user wrote the goal and chat in
another language (e.g. Ukrainian).
Add a shared language policy ("mirror the user's language") injected into all
three task LLM prompts: buildTaskPlanPrompt, buildExecutionPrompt, and the
Ask first-turn message. The policy is split per call site so machine-parsed
tokens stay intact while only human-facing prose is translated:
- plan: keep the `agent:` / `human:` step prefixes English, titles localized
- execution: keep protocol markers (TASK_DONE, NEEDS_HUMAN, ASK_USER,
NEEDS_REVIEW, ADD_HUMAN_STEP) verbatim, explanation localized
- ask: pure prose, no tokens
Chat-title generation already instructs "use the same language as the user",
so it's left as-is. No JS language detection (brittle across scripts) — the
model mirrors reliably.
typecheck clean; taskRunner tests pass (13).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes
dev→main. Merging triggerspublish.yml→ npm publish@iclawapp/iclaw@0.2.0(currently published: 0.1.4). 49 commits.Highlights
Remote Access (E2E alpha)
Security audit fixes (client side of the relay hardening)
Features
Fixes: settings/sidebar search, chat-list virtualization jitter, Remote Access UX polish.
Pre-merge state
Note
Version in package.json is already 0.2.0; on merge
publish.ymlpublishes it (skips if already published). Consider updating CHANGELOG for 0.2.0.🤖 Generated with Claude Code